关于 TypeScript 迁移到 Go,你应该知道的
本文的内容节选自:微软为何选择用 Go 而非 Rust 重写 TypeScript
我们为什么决定迁移
我们一直在观察 TypeScript 运行时间的缓慢增长,或者说是逐渐变慢的趋势。因此,我们做了一些性能优化的尝试,并进行了一系列改进。但这些优化通常只能带来 5% 或 10% 的提升,并没有实质性的突破。我们逐渐意识到,我们的优化空间已经接近极限了。
当我们用性能分析工具(Profiler)查看 TypeScript 编译器的运行情况时,我们发现它没有明显的性能瓶颈(Hotspots)。它已经尽可能快地运行了,所有的优化方式都已经被用尽。
因此,在去年 8 月,我们开始思考:如果我们将 TypeScript 迁移到原生代码,会带来怎样的影响?我们需要获取一些数据,从而做出更明智的决策,判断是否值得进行这次迁移。
于是,我们开始用不同的语言进行原型开发(Prototyping)。我们尝试了 Rust、Go、C 以及其他一些语言。最终,我们发现 Go 非常符合我们的需求。
在 8 月,我开始将 TypeScript 的词法分析器(Scanner)和解析器(Parser)迁移到 Go,以建立一个基准(Baseline),看看它的性能会有多快,以及从 JavaScript 迁移到 Go 的难度究竟如何。
在短短几个月内,我们就实现了一个可以运行的版本,它能够解析我们所有的源代码,并且不会报错。从这个阶段开始,我们便能推测出一些性能数据。我们逐渐意识到,这次迁移可以让 TypeScript 的性能提升 10 倍!
其中,大约 3 到 3.5 倍 的提升来自于原生代码的执行效率,而另外 3 到 3.5 倍 则来自于并发执行(Concurrency)。两者结合后,我们可以实现 10 倍的性能提升。
简单来说,原来使用 JavaScript 的实现已经进入了性能瓶颈,选择 Go 这个更原生的语言能获得很大的性能提升。
为什么不使用 Rust 而是 Go?
其中一个关键因素是,我们是在迁移现有代码,而不是从零开始。如果我们是从零开始,那么选择哪种语言可以根据项目需求来决定。例如,如果我们从零开始编写 Rust,我们会从一开始就设计一个不依赖自动垃圾回收(GC)、不过度依赖循环引用的编译器。
但现实是,我们的产品已经有十多年的历史,有数百万的程序员在使用,还有数百万行代码在运行。因此,我们不可避免地会遇到各种兼容性问题。我们的编译器中有很多行为是“随意”决定的---比如在类型推导中,可能有多个候选项都是正确的,而我们的编译器会选择其中一个。这种行为实际上已经成为很多程序依赖的特性。如果新的代码库在这方面的处理方式不同,就可能引发新的错误。
所以,从一开始,我们就知道唯一可行的方案是迁移现有代码库。而现有代码库有一些基本假设,其中之一就是依赖自动垃圾回收。这个前提基本上就排除了 Rust,因为 Rust 没有自动 GC。
在 Rust 中,你可以使用手动内存管理、引用计数等方式,但 Rust 还有一个额外的限制:借用检查(Borrow Checker),它对数据结构的所有权管理非常严格,尤其是禁止循环数据结构。而我们现有的代码库中,循环数据结构无处不在,比如:
AST,, 既有子节点指向父节点,也有父节点指向子节点。
符号表里的符号可能引用声明,而声明又可能回溯引用符号。
类型系统也是高度递归的,存在大量循环引用。
如果要适配 Rust,我们就必须重新设计所有这些数据结构,这会让迁移到原生代码的难度变得难以逾越。因此,我们需要一种语言,它既能生成高效的原生代码,又能支持循环数据结构,同时还必须具备自动垃圾回收。
我个人对 Rust 充满激情,但我也清楚 Rust 并不是一门“可以在一个周末学会的语言”。Rust 关注的是尽可能正确,即使这会影响开发体验,,。
对于 JavaScript 开发者来说,Go 的学习曲线显然比 Rust 低得多,这一点我深信不疑。
为什么没有选择 C#?
Go 是我们能选择的最低级别的语言,同时仍然具备自动垃圾回收。它是最接近原生的语言,同时还提供 GC。相比之下,C#语言更像是“字节码优先”的语言,虽然某些平台上有 AOT(Ahead-of-Time)编译选项,但它并不适用于所有平台,从某种程度上来说,C 语言并没有经过十多年的严格打磨,它最初的设计目的也不是为了我们这样的应用。而 Go 在数据结构布局和内联结构体(inline structs)方面更具表现力,这对我们来说是一个很大的优势。
此外,我们的 JavaScript 代码库采用了高度函数式的编程风格,我们几乎不使用类(classes),事实上,核心编译器部分根本不使用类。而 Go 也具有类似的特性,它主要由函数和数据结构组成,而不像 C# 那样高度面向对象(OOP)。如果我们选择 C#,就必须切换到面向对象的范式,这会增加迁移的阻力,而 Go 则是阻力最小的选择。
这次迁移的目标?
我们的目标是尽可能忠实地保持原有的行为。我们保留了所有相同的类型,数据结构的布局方式也与 JavaScript 版本一致。当然,在 JavaScript/TypeScript 里,我们大量使用联合类型(union types)、交叉类型(intersection types),以及一些 Go 里没有的高级类型系统特性,因此我们的类型声明方式会有所不同,但核心逻辑仍然保持一致。从语义上讲,我们讨论的仍然是相同的概念。这一点适用于符号(Symbols)、对象模型(Object Model)以及编译器内部的类型系统。
我们的目标是 99.99% 的兼容性。理想情况下,我们希望对相同的代码基生成完全一致的错误信息。这正是我们一直在努力的方向。